﻿using System;
using System.Collections.Generic;
using System.Data;
using System.IO;
using System.Reflection;
using System.Xml;

namespace Fadd.Data.Migration
{
    /// <summary>
    /// As the name implies the migrator uses sql specified for a certain driver to update the database.
    /// The sql is loaded using manifest resource xml files named xxxxx.fdmig.xml (fadd data migration)
    /// </summary>
    /// <remarks>
    /// The fdmig file should look like the following
    /// <![CDATA[
    /// <?xml version="1.0" encoding="utf-8" ?>
    /// <migrations>
    ///     <migration name="My migration" version="0002">
    ///         <description>
    ///             An optional description of the migration.
    ///         </description>
    /// 
    ///         <sql driver="MySql 1.0">
    ///             alter table myTable rename column myColumn to myColumnValues;
    ///         </sql>
    /// 
    ///         <sql driver="SqlServer">
    ///             alter table myTable rename column myColumn to myColumnValues;
    ///         </sql>
    ///     </migration>
    /// 
    ///     <migration name="My next migration" version="0003" description="A short description">
    ///         <!-- default driver will be used if specified and db doesn't match any other driver -->
    ///         <sql driver="default">
    ///             INSERT INTO myTable (myId, myValue) VALUES (13, '23123');
    ///         </sql>
    ///     </migration>
    ///     
    /// </migrations>
    /// ]]>
    /// </remarks>
    public class SqlXmlMigrator<MigrationModelType> : IMigrator where MigrationModelType : IMigrationModel, new()
    {
        private readonly Assembly[] _assemblies;
        private readonly SortedList<int, SqlMigration> _migrations = new SortedList<int, SqlMigration>();
        private int _highestVersion;
        private int _lowestVersion = int.MaxValue;

        /// <summary>Initializes the <see cref="SqlXmlMigrator{MigrationModelType}"/></summary>
        /// <param name="assemblies">Assemblies to look for fdmig.xml files in</param>
        public SqlXmlMigrator(params Assembly[] assemblies)
        {
            Check.Require(assemblies, "assemblies");

            _assemblies = assemblies;
        }

        public void Migrate(DataLayer dataLayer)
        {
            FetchMigrations();
            //if(_migrations.Count == 0)
              //  return;

            // Test migration
            if (TestMigration(dataLayer))
                return;

            // If migration failed we'll try if the first migration will fix it
            if (!_migrations.ContainsKey(_lowestVersion))
                throw new MigrationException("Could not find migration table or suitable first migration");

            ApplyMigration(dataLayer, _migrations[_lowestVersion]);
            TestMigration(dataLayer);            
        }

        private bool TestMigration(DataLayer dataLayer)
        {
            string tableName = dataLayer.MappingProviders.GetMapping(typeof(MigrationModelType).Name).TableName.ToLower();

            try
            {
                MigrationModelType lastMigration = dataLayer.Find<MigrationModelType>(new Statement("VersionNumber > ?", 0).OrderBy("VersionNumber DESC"))[0];
                if (lastMigration.VersionNumber >= _highestVersion)
                    return true;

                for (int i = lastMigration.VersionNumber + 1; i <= _highestVersion; ++i)
                {
                    if(!_migrations.ContainsKey(i))
                        throw new MigrationException("Could not find migration version: " + i);

                    ApplyMigration(dataLayer, _migrations[i]);
                }

                return true;
            }
            catch (Exception e)
            {
                // If the errror contains something like 'tablename does not exist' we will try to create the table using migration
                string error = e.Message.ToLower();
                if (!error.Contains(tableName) || !error.Contains("not") || !error.Contains("exist"))
                    throw;
            }

            return false;
        }

        private static void ApplyMigration(DataLayer dataLayer, SqlMigration migration)
        {
            string driver = dataLayer.ConnectionHelper.GetType().Name;
            string sql;
            if(!migration.Sql.TryGetValue(driver, out sql))
                if(!migration.Sql.TryGetValue("default", out sql))
                    throw new MigrationException("Could not find sql for migration version '" + migration.Version + "' and driver: " + driver);

            try
            {
                using (Transaction transaction = (Transaction)dataLayer.CreateTransaction())
                {
                    transaction.Execute(sql, new List<object>());
                    MigrationModelType migrationUpdate = new MigrationModelType
                                                             {
                                                                 VersionNumber = migration.Version,
                                                                 UpdateTime = DateTime.Now
                                                             };
                    transaction.Save(migrationUpdate);
                    transaction.Commit();
                }
            }
            catch (Exception e)
            {
                throw new MigrationException("Failed to execute sql migration version " + migration.Version, e);
            }
        }

        /// <summary>Reads all manifest files ending with fdmig.xml and creates migrations from them</summary>
        private void FetchMigrations()
        {
            foreach (Assembly assembly in _assemblies)
                foreach (string manifestResourceName in assembly.GetManifestResourceNames())
                    if(manifestResourceName.EndsWith("fdmig.xml"))
                        ParseMigrationXml(assembly, manifestResourceName);
        }

        private void ParseMigrationXml(Assembly containingAssembly, string manifestResourceName)
        {
            using(Stream stream = containingAssembly.GetManifestResourceStream(manifestResourceName))
            {
                if(stream == null)
                    throw new MigrationException("Could not read manifest resource file from assembly '" + containingAssembly.FullName + "': " + manifestResourceName);

                using (XmlTextReader reader = new XmlTextReader(stream))
                {
                    reader.WhitespaceHandling = WhitespaceHandling.None;
                    if (reader.Name == "xml")
                        if(!reader.Read())
                            throw new MigrationException("Invalid xml in migration file, missing node after 'xml' header node: " + manifestResourceName);

                    do
                    {
                        if(reader.Name == "migration")
                            ParseMigration(reader);

                    } while (reader.Read());
                }
            }
        }

        /// <summary>Parses the current 'migration' element in the reader and adds its value to the collection of migrations</summary>
        /// <param name="reader">The reader to parse the migration from</param>
        /// <exception cref="ArgumentNullException">If <paramref name="reader"/> is null</exception>
        /// <exception cref="MigrationXmlException">Throw if parsing failes due to invalid data in xml</exception>
        public void ParseMigration(XmlTextReader reader)
        {
            if(reader == null)
                throw new ArgumentNullException("reader");

            if(reader.Name != "migration")
                throw new MigrationXmlException("Element on line " + reader.LineNumber + " is not of type 'migration'.");

            if (reader.NodeType != XmlNodeType.Element)
                throw new MigrationXmlException("Migration element on line " + reader.LineNumber + " is end element.");

            string versionString = reader.GetAttribute("version");

            int version;
            if(!int.TryParse(versionString, out version))
                throw new MigrationXmlException("Migration element on line " + reader.LineNumber + " lacks a proper 'version' attribute.");

            if (_migrations.ContainsKey(version))
                throw new MigrationXmlException("Migration element on line " + reader.LineNumber + " specifies a version number that has already been added.");

            SqlMigration migration = new SqlMigration
                                         {
                                             Name = reader.GetAttribute("name"),
                                             Version = version,
                                             Description = reader.GetAttribute("description")
                                         };

            if (_highestVersion < version)
                _highestVersion = version;
            if (version < _lowestVersion)
                _lowestVersion = version;

            reader.Read();
            while(true)
            {
                if(reader.NodeType != XmlNodeType.Element)
                    continue;

                if (reader.Name == "sql")
                {
                    string driver = reader.GetAttribute("driver");
                    if (string.IsNullOrEmpty(driver))
                        throw new MigrationXmlException("Sql element on line " + reader.LineNumber + " lack a 'driver' attribute.");

                    if (migration.Sql.ContainsKey(driver))
                        throw new MigrationXmlException("Sql element on line " + reader.LineNumber + " specifies a driver that has already been added.");

                    string sql = reader.ReadElementContentAsString();
                    if (string.IsNullOrEmpty(sql))
                        throw new MigrationException("Sql element on line " + reader.LineNumber + " lacks proper sql content.");

                    migration.Sql.Add(driver, sql.Trim());
                }
                else if (reader.Name == "description")
                    migration.Description = reader.ReadElementContentAsString();
                else if (reader.Name != "migration")
                    if(!reader.Read())
                        break;

                if (reader.Name == "migration" && reader.NodeType == XmlNodeType.EndElement)
                    break;
            }

            _migrations.Add(migration.Version, migration);
        }
    }
}
